Lambdaのテスト領域に関する技術共有会を開催しました
はじめに
CX事業本部の佐藤智樹です。
今回は先月中頃に実施したLambdaのテスト領域に関する技術共有会の資料と当日にいただいた意見を紹介します。技術共有会自体はお客様含め5人ほどで実施予定でしたが、社内の方を誘ったところ15人程と大人数でディスカッションしながら知識を深めました。有意義な時間となったのでブログで共有します。
本記事はLambdaに対してどのようなテストをすべきか、Lambdaでこれからテストを書くがどうやれば良いか悩んでいる方などは参考になるかと思います。いくつか紹介するテストパターンのメリット/デメリットもあげるので、テスト選定の上で参考にしてください。
例となる題材がなければ抽象的な話ばかりになり分かりづらくなるので、今回は以下のIoTデータ収集システムをベースにどうテストを書いていくか検討します。IoTデバイスからきたデータをRDSに保存するシステムです。色々書いていますがLambdaと直接つながっているサービスをメインに意識してもらえれば大丈夫です。
適宜紹介するサンプルコードはTypeScript
とJest
で記述します。言語が違ったとしても考え方は変わらないので参考になるかと思います。
実施の目的
Lambdaを使用する場合に限定せず、新しいPJで開発/テスト実装を開始する際は以下の考えが重要になります。
- テストという言葉は、今まで経験した会社/所属/PJ/チームによって命名も方法もテスト領域も異なる。
- 共通してどれを何と呼ぶのか定義する。
上記を考えた上でPJにマッチするテスト手法を選択したい。
参考資料
以下の参考資料をベースに考え方をまとめているので、こちらで紹介した資料を軽く確認いただいた方がスムーズに理解できると思います。
Lambdaでテスト書くなら最初に読むことになる資料。基本的に上記資料をベースとして、PJでのテスト領域を明確にしていきます。資料だけでなく動画もあってそちらの方が詳しく話をされているので、詳細が気になる方は動画をおすすめします。
技術共有会時には話忘れましたが、改めて資料を見返すと自分のLambdaのテストに関する考え方はこちらの資料がベースになっていることに気づいたので追記しました。途中コードのアーキテクチャ図も出てくるので、ピンとこない部分があればこちらの資料ご参照ください。
スタブ・スパイ・モックなどのテストダブルに関する用語はこちらがベースとなります。
「Testable Lambda~」でも引用されている「xUnit Test Patterns」の広義のモック、狭義のモックが軽く書かれていたり、Jest特有の仕様(spyなど)が書かれています。今回紹介するコードで厳密にテストダブルの分類を考えると分かりづらい部分(この機能はスタブ?モック?など)があるので、参考程度にご確認ください。
テスト用語の整理
今回のPJでテストダブルそれぞれの用語が何を表すものか記載します。テストダブルに関連する資料を読んだだけだと、どのテストフレームワークや言語にも囚われない抽象的な表現で一見分かりづらいため、チーム内ではまとまった見解の方が良いと考えて記載しました。テストダブルの中で特に使用する下の3つを定義します。
Mock
テストしたいファイルが呼び出すクラスやメソッドをコードでモック化(モック兼スタブとして代替品の役割をもつ)することを指します。(次章以降でコードの具体例を紹介します)
Fake
LocalStackやローカルのPostgresサーバなどを使用してローカルでフェイクサービスを利用することを指します。
Spy
メソッドの内部に入り込み呼び出し回数や内部のデータ、返り値などを観測することを指します。(Jestの場合は基本スタブの役割は持たない) ※所感ですが基本はあまり使用しない想定。AWS SDK内部の挙動やプライベートメソッドをどうしても確認したい場合のみ使用。
クラウドサービスを活用する場合のテスト
AWS上などのクラウドサービスを使用する場合は、通常の仮想マシンのみで完結するアプリケーション開発と若干異なる部分があります。耐障害性やセキュリティの考慮や保守の手間を減らすため、AWS固有のマネージドサービス(S3やSSM、SQSなど)を使う場合です※。仮想マシン単体のテストだけでは正常に動作するか確認できないため、テストをどこまで行うのかという観点が重要になります。
※AWSへの6つの移行戦略で言うところの4.リファクタリングに当たるクラウドサービスに最適な形で構築する方法です。 AWS への移行:ベストプラクティスと戦略
最初に示した以下のアーキテクチャ図をベースにテスト領域について確認したい内容を記載します。次章以降でテスト対象となる領域を色枠で記載します。
またLambda内のコードのアーキテクチャも関係するので、今回は説明のため以下のクリーンアーキテクチャを模した図(画像右の図)を使用します。ほぼ元の図と変わらないですが、ControllersをLambda実行時に最初に動くHandlerと解釈して構成しています。またLambdaのコード外の部分をAWS Service
として構成しています。
引用(画像左側の図):「The Clean Architecture」
クリーンアークテクチャの図は真似ていますが、基本的にレイヤーごとに役割を分けてコードを書いている場合を想定しているだけで、依存関係などの話はしないのでレイヤードアーキテクチャと思ってもらっても問題ないです。レイヤー分けについては参考資料を念頭において記載しています。
テストの分類
テストには大まかに以下のパターンがあります。
- 単体テスト(Mock/Spy)
- 単体テスト(Fake)
- 結合テスト(handlerを実行してテスト)(Mock/Spy)
- 結合テスト(handlerを実行してテスト)(Fake)
- E2Eテスト(AWS環境上でテスト)
上記を出した時点でチームや個人によって見解が異なる場合があるので、話し合って整理した方が良いです。今回は技術共有会当日と同じように上記をそれぞれ紹介していきます。
単体テスト(Mock/Spy)
S3、RDSへのアクセスクライアントやhandlerなどの独立したクラス/メソッドに対するテスト。クリーンアーキテクチャを模した図で各コンポーネントをレイヤーで分離して考えた場合、S3やRDSなどinfraと接続するクライアントやhandler、usecase単体に閉じたテストです。レイヤーごとにコンポーネントを切って単層だけをテストし、他の層はモック化するという考えです。
アーキテクチャ図で言うとLambda内部単体に閉じたテストです。Lambdaの実行ではなくローカルのメソッドをテストコードから実行してテストします。
サンプルのコードも記載します。それぞれInfra層でS3からデータを取得するコードと、AWS SDKをモック化してテストしているコードです。
テスト対象のコード:
import * as AWS from "aws-sdk"; interface IS3DataCollector { bucketName: string; objectKey: string; } export class S3DataCollector { s3DataCollectObject: IS3DataCollector; s3Client: AWS.S3; constructor(s3Client: AWS.S3, s3DataCollectObject: IS3DataCollector){ this.s3Client = s3Client; this.s3DataCollectObject = s3DataCollectObject; }; getObject = async () => { const params: AWS.S3.GetObjectRequest = { Bucket: this.s3DataCollectObject.bucketName, Key: this.s3DataCollectObject.objectKey }; const iotPayload = await this.s3Client.getObject(params).promise(); return iotPayload.Body!.toString(); }; }
テストコード:
import * as fs from "fs"; import * as path from "path"; import * as AWS from "aws-sdk"; import { S3DataCollector } from "../../src/lambda/s3-data-collector"; const bucketName = "test"; const testFileName = "sample-data.json"; const s3 = new AWS.S3({ region: "ap-northeast-1", signatureVersion: "v4", }); describe("s3 getObject", () => { test("S3からデータが取得できる", async () => { // AWS SDKのS3インスタンスのメソッドをモック化 s3.getObject = jest.fn().mockReturnValue({ promise: jest.fn().mockReturnValue({ Body: fs.readFileSync(path.join(__dirname, "events", testFileName)).toString() }), }); const s3DataCollector = new S3DataCollector( s3, { bucketName, objectKey: testFileName } ); const result = await s3DataCollector.getObject(); expect(result).toEqual( fs.readFileSync(path.join(__dirname, "events", testFileName)).toString() ); }); });
利点
- CI/CDへ簡単に組み込める
- テストの実行速度が早い
- 修正して再実行の開発サイクルを早く回せる
- Clean ArchitectureでいうEnterprise Business Rulesが肥大化してきた場合に有効
欠点
- クラス/メソッド単体のロジックしかテストできない
- AWS SDKなどをモック化するので引数が正しいかは静的解析以上の検証ができない
総論や感想
CI/CDの組み込みが容易なのと実行速度も早いので簡単に実践できます。基本的にはこの手法とE2Eがメインでも良さそうです。個人的な感想として特に開発時だとデメリット部分の下の方が頻繁にあり、DynamoDBの引数が正しいのかデプロイするまで検証できないという問題があったりしました。
単体テスト(Fake)
S3、RDSへのアクセスクライアントのアクセス先をFake(LocalStackやローカルのPostgresサーバ)に置き換えるテストです。ローカル環境でAWSのサービスを使用せずに高速にテストするための方法です。
クリーンアーキテクチャを模した図で表すと、Infra層とAWS Serviceの接続部分(AWS SDKなど)のテストです。 HandlerやUse Casesは独立するので、フェイクサービスとの関連はありません。
こちらもLambdaの実行ではなくローカルのメソッドをテストコードから実行してテストします。Lambdaと1フェイクサービスに閉じたテストです。図の赤、青、黄色枠線でそれぞれ別のテストを書くイメージです。
以下にテストコードを記載します。テスト対象のコードはモックと同様です。コードから分かるようにこちらはAWS SDKをモック化していないので、AWS SDKへ渡す値の検証や挙動の確認もできます。テスト対象のコードはMockのものと変わりないため省きます。
テストコード:
import * as fs from "fs"; import * as path from "path"; import * as AWS from "aws-sdk"; import { S3DataCollector } from "../../src/lambda/s3-data-collector"; const bucketName = "test"; const testFileName = "sample-data.json"; const s3 = new AWS.S3({ region: "ap-northeast-1", signatureVersion: "v4", s3ForcePathStyle: true, endpoint: new AWS.Endpoint("http://localhost:4566") }); // LocalStackのS3リソース作成/削除に時間がかかるため追加 jest.setTimeout(10000); describe("s3 getObject", () => { beforeAll(async () => { // LocalStack上にFakeのバケットとオブジェクトを作成 await s3.createBucket({ Bucket: bucketName }).promise(); await s3.putObject({ Bucket: bucketName, Key: "sample-data.json", Body: fs.readFileSync( path.join(__dirname, "events", testFileName) ), }).promise(); }); afterAll(async () => { // テスト後、テスト用に生成したオブジェクトを削除 const deleteObjects: AWS.S3.Types.ListObjectsOutput = await s3.listObjects({ Bucket: bucketName }).promise(); if(deleteObjects.Contents){ for (const deleteObject of deleteObjects.Contents){ await s3.deleteObject({Bucket: bucketName, Key: deleteObject.Key as string }).promise(); } } await s3.deleteBucket({ Bucket: bucketName }).promise(); }); test("S3からデータが取得できる", async () => { const s3DataCollector = new S3DataCollector( s3, { bucketName, objectKey: testFileName } ); const result = await s3DataCollector.getObject(); expect(result).toEqual( fs.readFileSync(path.join(__dirname, "events", testFileName)).toString() ); }); });
利点
- AWS SDK周りの使用方法に関する試験なども行える
- AWS環境にデプロイする場合と比較してテストの実行速度が早い(E2Eの場合はデプロイ->テスト->完了までに5分程かかることもある)
- 修正して再実行のサイクルも早い
欠点
- CI/CDへの組み込みは要検討
- Fake特有の知識や実装方法(Test Logic in Productionにしないための実装など)が必要。 参考:「Testable Lambda: Working Effectively with Legacy Lambda」 P35
- Fakeにバグがある場合の切り分け判断が難しい。「コードが悪いか確認->設定が悪いか確認->AWSにデプロイして動作確認->モックが悪い」のような確認の流れになる。
- うまくレイヤー分けできればそもそもテスト対象にできる部分が薄く、AWSサービスのテストになってしまう 参考「TypeScriptとJestではじめる AWS製サーバーレス REST API のユニットテスト・E2Eテスト」 P28
総論や感想
既存動作が壊れているかの検証という観点ではAWS自体のテストとなってしまい存在は薄いですが、開発時にAWSの挙動を確認する場合には有効な手法かと思います。個人的な感想として、昔Fake特有のバグに泣かされたことがあったので避けてました。ただHumbler Objectの設計を極めたPJで開発していたときにFakeがあればもう少し開発サイクル早くできたかなと振り返ってました。
結合テスト(handlerを実行してテスト)(Mock/Spy)
S3やRDSなどを一通りモック化して、handler関数から全体の処理を実行するパターンです。殆ど「単体テスト(Mock/Spy)」と変わりはないですが、1イベントの処理全体を通した確認ができます。クラス/メソッド同士の結合をテストしたい場合に使用します。
クリーンアーキテクチャを模した図で表すと、各層が正しく連携できているか確認するためのテストです。
アーキテクチャ図で表すと、以下のようにLambda内部に閉じたテストになります。
結合テストの利点/欠点も上げていきます。ただ基本的に単体テストの場合と変わりないものが多く、その分は省くのでかなりあっさり目の記載になります。
利点
- Lambda内部のコード全体を通したテストができる。後は「単体テスト(Mock/Spy)」と変わりなし
欠点
- ロールやAWSサービス固有の制限(Lambdaの同時実行数制限やIP上限など)のテストはできない(単体も同様)
総論や感想
レイヤーを結合したテストがローカルで高速で試せます。この工程を飛ばしてE2Eを実装することもありますが、レイヤーの繋ぎ込み部分でエラーや実装ミスが多い場合は選択肢に入るかと思います。
結合テスト(handlerを実行してテスト)(Fake)
S3やRDSなどを一通りFake化して、handler関数から全体の処理を実行するパターンです。こちらも全体通してテストできること以外は、「単体テスト(Fake)」と変わりないです。
クリーンアーキテクチャを模した図で先ほどの結合テストと違う部分を書くと、AWS Service
との境界部分もテスト対象に入ります。
アーキテクチャ図で表すと、以下のようにLambda外部ともFakeで繋がって通しでテストできます。
利点
- Lambda内部のコード全体(AWS SDK含む)を通したテストができる。後は「単体テスト(Fake)」と変わりなし
欠点
- 画像の×部分(S3->SQS)やSQS->Lambdaが正しく連携できるのか担保できない※。
- ロールやAWSサービス固有の制限(Lambdaの同時実行数制限やIP上限など)のテストはできない(単体も同様)
※SQS->LambdaはSQSをFakeとして作成することで一応できるが、S3->SQSまでLocalStackで再現するとFake同士の連携まで自前で書く必要がある。自前で作ってもあくまでFakeなので、一部はFake自体のテストになってしまう。S3->SQS->LambdaなどLambdaが実行されるまでのイベントはAWS上でのE2Eテストで担保すべき部分だと考えている。
総論や感想
ローカルでAWS SDKを含めたコード全体を通した確認もできます。ただFake自体のテストが発生するのとサービス固有の問題が確認できないので完全にこの方式に依存するのは避けたいところです。
E2Eテスト(AWS環境上でテスト)
AWS環境に実際にLambdaをデプロイして、偽のイベントでAWSサービスをキックして動作確認するテスト。クリーンアーキテクチャを模した図でいうと、コード外部のAWS Serviceを含めたテストです。
アーキテクチャ図でいうと赤枠のように、S3上へファイルを配置することでPUTイベントを発生させてRDS上にデータが保存されることを確認したり、青枠のようにIoT RuleへPublishしてからRDS上にデータが保存されることを確認したりするテストです。ローカルで確認できないAWSサービス固有の設定の正常確認ができます。
利点
- IAMロールなど権限周りに関するテストが可能
- 本番相当の環境(STG環境で動作させる想定)なので、Mock/Fakeと本番の差異による問題が起きない
- Lambda固有の制限(同時実行数制限やIP上限など)、SQSのリトライ回数など実際動作させなければ気付きにくい問題も確認
- 負荷テストにもコードを流用できる
欠点
- テスト実行に費用がかかる(規模による)
- 複数アカウントの準備(ITG、STG、PRD)が必須(無い場合はロジックが必要)
- 実行時間が遅い。デプロイ->テスト実行->結果までに5分程かかる。テストを厚く書くと再試行がさらに長くなるのでE2Eでテストしない範囲を決める必要がある。並行実行が課題、早く実行する手立てが少ない。
- ローカルからテスト実行する場合ネットワーク不調などロジック以外の部分でテストが失敗する
- 上記を防ぐためにはLambdaやEC2、Fargateなどでテスト実行基盤を作る必要がある
総論と感想
本番と同じ環境でテストできるので、テスト通りに本番で動く安心感があります。最低でも正常系1本は作った方が開発が安定するように感じます。特に長い間やっていた手法なので欠点を結構書いてますが、良いテスト方法だと思っています。ただ闇雲にテストパターンを増やすと、実行時間が増すのでテスト実行基盤を作れない場合はテストピラミッドとアイスクリームコーンを意識して開発に取り組むことが重要です。
ディスカッションでいただいた意見
上記の話を途中気になる部分ツッコミいれてもらいながら15~20分話した後、各自の経験を含めてディスカッションしました。コメントをいくつかもらったので紹介します。(技術共有会が半月前で記憶が若干薄れているので、「俺のコメント無いじゃん!」などあればメンションください)
モックを作るのに使っている各種ツールがスタブやスパイをするのに申し分なく使えるからよく混乱する。
同感です、JestはMock兼Stubだったりしますね。他のテストツールがSpy兼StubであるところがSpyだけだったりとテストフレームワークによって実装が違うので厳密に考えるとどの言葉を使えば良いのか結構悩みました。今回はその曖昧さもあったのでテストダブルの言葉の整理を最初にしました。
親の顔より見た図(クリーンアーキテクチャの図に対して)
見飽きるぐらい見ましたが自分は未だに正しい理解ができてない感じがありますね。
ビジネスロジックだけのコンポーネントのテストなら、Mock/Spy がいらなそう
まさにそうかなと思います。今回整理して改めて思いましたが、ビジネスロジックをうまく切り出したらMock/SpyもFakeもいらないテストが書けそうです。
「マイクロサービスを成功させるためのサーバーレスアーキテクチャ設計とNoSQLデータモデリング」 P41(テスト実行基盤に関する話の時)
上記の資料にもある通り、時間がかかるので今回の記事でE2Eテストと呼んでいるテストはCI化したほうが良さそうです。家のネットワークが弱いので結構泣かされました。
取り上げた5つのテストパターンに追加して、ローカル環境からAWSを使用してテストするパターンもありそう
あまりやったことは無いですがそういうパターンもありですね。Fakeの実装パターンを応用すれば実装出来そうです。自実現できればLambdaのデプロイが不要になるので早くテストできます。ただロールのテストは組み込めるのか想像つかないです。
E2Eとテストパターンをまとめているが、API Gatewayなどのテストの場合はシナリオベースのE2EとAPIに関するE2Eは分けた方が良さそう
その通りですね。混同してE2Eと書いてましたが正常系/異常系のAPI動作確認と、個別のPJに関連するシナリオベースのテストは分けて書いた方がテストケースの漏れが少なくなりそうです。
LocalStackをS3やDynamoDBなどで利用していたが、使っていたときLocalStack特有のバグには遭遇しなかった。
他のPJでLocalStackを使っていたチームの方の発言です。自分がたまたま昔Fakeのバグにあたっただけで、そこまで問題は起きていないみたいです。
上記で挙げた以外にもカバレッジの話や各のPJではどう意識してテストしているかなどの話が聞けて非常に有意義な時間でした。
感想
まず技術共有会にご参加いただいたお客様、IoT、MADチームの方々ありがとうございました。皆さんに参加いただいたおかげで自分の知見をさらに深めることができました。今回の内容が皆さんの中でも何か考えるきっかけになればありがたいです。
特にLocalStackなどFakeサービス全般に関して自分はどちらかというとネガティブ寄りな感想を持っていましたが、今回他のチームの話も聞いて改めて考えを整理するとポジティブな感想に変わりました。今やっているPJではLocalStackを積極的に使ってみようと思います。
元々システムテストの設計や実施をメインの仕事としていた時期もあったので、無駄に力の入った部分もあります。書いたことで自分の中のテストに関する言葉の整理とチームで話す時の共通語彙が作れたのでよかったです。
他のチームの方からも資料が欲しいと言っていただいたり、自分も悩んでいたのである程度需要あるかなと思いブログ化しました。この資料を読んで、何かフィードバックがあればTwitterやはてぶコメントなどでいただけると助かります。